package com.conversationboard.validate.bbcode;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.conversationboard.config.Configuration;

public class Validator {

    private static final String[] bbCodeTagList = { "b", "i", "s", "u", "q", "code", "img" };
    private static final String[] bbCodeExtendedTagList = { "url" };
    private static StringBuffer bbCodeTagListAlternating = new StringBuffer("");
    private static StringBuffer bbCodeExtendedTagListAlternating = new StringBuffer("");

    // Patterns cribbed from InputChecker. Several characters added to
    // illegalChars.
    private static Pattern illegalChars = Pattern.compile("([<>^\"`{}\\[\\]\\\\]|java.*script|" + Configuration.getInstance().getRoot() + "/Pages/Admin|" + Configuration.getInstance().getRoot() + "/Admin)", (Pattern.DOTALL | Pattern.CASE_INSENSITIVE));
    private static Pattern illegalLink = Pattern.compile("(Admin.*ControllerServlet|LogoutControllerServlet)", (Pattern.DOTALL | Pattern.CASE_INSENSITIVE));

    private static final String tagMatcherRegex = "([\\[\\]{}])" // 1:
            // Opening
            // or
            // closing
            // bracket
            // (or
            // brace).
            + "([\\\\/]*)" // 2: Misplaced / or \.
            + "([a-z]+)" // 3: BBcode tag body.
            + "(?:=([^\\[\\]{}\\s]*))?" // 4: Tag "attribute".
            + "([\\\\/]*)" // 5: Misplaced / or \.
            + "([\\[\\]{}])"; // 6: Closing bracket (or brace).

    private static final Pattern tagMatcher = Pattern.compile(tagMatcherRegex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

    private static final Pattern outerSpacePattern;
    private static final Pattern innerSpacePattern;

    private static final Pattern correctLeftTagCounter;
    private static final Pattern correctRightTagCounter;

    private static final Pattern nestedBbCodePattern;

    private static final int magicContextLength = 64; // The number of
    // characters to
    // preserve on each side
    // of an error.
    private String checkedString = "";

    static {
        synchronized (Validator.class) {
            Arrays.sort(bbCodeTagList);
            Arrays.sort(bbCodeExtendedTagList);

            // This seems like an ugly way to do $bbCodeTagListAlternating =
            // join('|', @bbCodeTagList);
            for (int i = 0; i < bbCodeTagList.length; i++) {
                bbCodeTagListAlternating.append(bbCodeTagList[i]);
                if (i < bbCodeTagList.length - 1) {
                    bbCodeTagListAlternating.append("|");
                }
            }

            // Necessary only in theory.
            for (int i = 0; i < bbCodeExtendedTagList.length; i++) {
                bbCodeExtendedTagListAlternating.append(bbCodeExtendedTagList[i]);
                if (i < bbCodeExtendedTagList.length - 1) {
                    bbCodeExtendedTagListAlternating.append("|");
                }
            }

            outerSpacePattern = Pattern.compile("(\\[\\s+" + "(?:" + bbCodeTagListAlternating + "|" + bbCodeExtendedTagListAlternating + ")" + "\\s*\\])" + "|" + "(\\[\\s*" + "(?:"
                    + bbCodeTagListAlternating + "|" + bbCodeExtendedTagListAlternating + ")" + "\\s+\\])", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);

            innerSpacePattern = Pattern.compile("(\\[" + "(?:" + bbCodeExtendedTagListAlternating + ")" + "[^\\[\\]{}]*\\s+[^\\[\\]{}]*\\])", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);

            correctLeftTagCounter = Pattern.compile("\\[" + "(" + bbCodeTagListAlternating + "|" + bbCodeExtendedTagListAlternating + ")" + "(?:\\]|(?<=" + bbCodeExtendedTagListAlternating
                    + ")(?:=[^\\[\\]{}\\s]+\\]))", // Match ] or a url string
                    // prefixed by url= and then
                    // ].
                    Pattern.DOTALL | Pattern.CASE_INSENSITIVE);

            correctRightTagCounter = Pattern.compile("\\[/" + "(" + bbCodeTagListAlternating + "|" + bbCodeExtendedTagListAlternating + ")\\]", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);

            nestedBbCodePattern = Pattern.compile("\\[(" + bbCodeTagListAlternating + "|" + bbCodeExtendedTagListAlternating + ")\\]" // First
                    // left
                    // tag.
                    + "(.(?!\\[\\1\\]))*.\\[\\1\\]" // Second left tag.
                    + "(.(?!\\[/\\1\\]))*.\\[/\\1\\]" // First right tag.
                    + "(.(?!\\[/\\1\\]))*.\\[/\\1\\]", // Second right tag.
                    Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
        }
    }

    
    public static void refresh() {
        illegalChars = Pattern.compile("([<>^\"`{}\\[\\]\\\\]|java.*script|" + Configuration.getInstance().getRoot() + "/Pages/Admin|" + Configuration.getInstance().getRoot() + "/Admin)", (Pattern.DOTALL | Pattern.CASE_INSENSITIVE));
        illegalLink = Pattern.compile("(Admin.*ControllerServlet|LogoutControllerServlet)", (Pattern.DOTALL | Pattern.CASE_INSENSITIVE));
    }
    

    public Validator() {
        checkedString = "";
    }


    public Validator(String input) {
        checkedString = (input == null) ? "" : input;
    }


    /**
     * Validate checkedString as BBcode. If validation fails, apply heuristic
     * tests to guess why.
     * 
     * @input input The string to be checked
     * @return An array describing any errors encountered, or null if none were.
     *         The first is a short description and the second and third are
     *         short pre- and post-error-context strings.
     */
    public static ArrayList<String> validate(String input, Boolean doValidate) {
    	
    	/* If we don't do validation of BBCode, just return null here */
    	
    	if (!Configuration.getInstance().isValidateBBCode()) {
    		return null;
    	}
    	
        if (doValidate == false)
            return null; /*
                             * This is a convenience "off switch" in case the
                             * class goes haywire. It might be useful to move
                             * this parameter into config.xml for easy admin
                             * manipulation.
                             */

        Validator bbCodeValidator = new Validator(input);
        ArrayList<String> errorList = new ArrayList<String>();

        try {
            bbCodeValidator.matchAllBrackets();
        } catch (BbTagFormatException e) {
            String preError = e.getErrorContext()[0];
            String postError = e.getErrorContext()[1];

            /*
             * Return magicContextLength characters of error context on either
             * end, max.
             */
            errorList.add(0, e.getErrorExplanation());
            errorList.add(1, preError.substring((preError.length() > magicContextLength) ? preError.length() - magicContextLength : 0));
            errorList.add(2, postError.substring(0, (postError.length() > magicContextLength) ? magicContextLength : postError.length()));

            return errorList;
        }

        return null;
    }


    /**
     * Attempt to parse the entirety of checkedString as BBcode. Iterate through
     * the string in chunks bounded by valid BBcode tags.
     * 
     * @return true on success
     * @throws BbTagFormatException
     */
    public boolean matchAllBrackets() throws BbTagFormatException {
        int start = 0;
        int end = checkedString.length();
        try {
            do
                start += matchBrackets(checkedString.substring(start, end));
            while (start > 0 && start < end);
        } catch (BbTagFormatException e) {
            /*
             * Check for potential error sources that are difficult or
             * impossible to pin down inside of matchBrackets. The tests below
             * are heuristic or "probabilistic", and should supplement, rather
             * than replace, the more rigorous tests in analyzeTag.
             */

            // Whitespace in tags is impossible to detect while allowing for
            // valid constructs like [abc def].
            // Hence, look for problems only after an exception has been thrown.
            e = checkForWhitespaceInTags(e);

            // Check whether there are an equal number of left and right tags of
            // each kind.
            e = countBalancedTags(e);

            // Check for nested tags.
            e = checkForNestedTags(e);

            // Add more heuristic checks here.
            throw e;
        }

        if (start < 0) {
            BbTagFormatException e = new BbTagFormatException("", 0, "Unable to match all tags");

            e = checkForWhitespaceInTags(e);
            e = countBalancedTags(e);
            e = checkForNestedTags(e);

            throw e;
        }

        return true;
    }


    /**
     * Parse the input string as BBcode with an eye to discovering errors. This
     * is a convenience stub for the recursively called version with 3
     * arguments.
     * 
     * @return >= 0 if the check completed OK, < 0 if it failed. The reason this
     *         isn't Boolean is the notion that more information can be passed
     *         in an int. Currently, this isn't really "implemented".
     * @throws BbTagFormatException
     */

    private int matchBrackets(String checkedSubstring) throws BbTagFormatException {
        ArrayList<String> seenTags = new ArrayList<String>();
        return matchBrackets(checkedSubstring, 0, seenTags);
    }


    /**
     * Parse the input string as BBcode with an eye to uncovering user mistakes.
     * 
     * @input checkedSubstring The portion of checkedString we're examining.
     *        Note that we're passing strings instead of offsets due to our
     *        reliance on regular expressions, which aren't cut out to use
     *        offsets.
     * @input offset The absolute offset from the start of checkedString. Used
     *        only to pass to BbTagFormatException to provide greater context in
     *        error descriptions.
     * @input seenTags A stack of nested BBcode tags that have been successfully
     *        "parsed". Used to detect whether there are nested tags of the same
     *        sort.
     * 
     * @return A nonnegative integer if the scan completed, a negative integer
     *         otherwise. See above.
     * @throws BbTagFormatException
     */

    private int matchBrackets(String checkedSubstring, int offset, ArrayList<String> seenTags) throws BbTagFormatException {
        Matcher leftMatcher = tagMatcher.matcher(checkedSubstring);
        String lastSeenTag = (seenTags.size() > 0) ? seenTags.get(seenTags.size() - 1) : null;

        if (leftMatcher.find()) {
            String leftTag = leftMatcher.group(3);
            String leftTagAttribute = leftMatcher.group(4);
            int leftTagEnd = leftMatcher.end(6);

            // Analyze the tag for obvious problems that are yet matched by the
            // regex.
            analyzeTag(leftMatcher, offset, "left", seenTags.size());

            // Check "URL attributes" for correctness.
            if (leftTagAttribute != null)
                checkTagContent(leftTagAttribute, leftTag, offset);

            // The left tag hasn't set off any alarms. Find the right tag. These
            // patterns could all be compiled in the synchonized{} block,
            // since they're finite, but that's kinda pushing the boundaries of
            // readability.
            Pattern rightTagMatcherRegex = Pattern.compile("\\[/" + leftTag + "\\]", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
            Matcher rightTagMatcher = rightTagMatcherRegex.matcher(checkedSubstring);

            int tagSearchStart = leftTagEnd;
            while (tagSearchStart < checkedSubstring.length()) {
                if (rightTagMatcher.find(tagSearchStart)) {
                    /*
                     * A mate for the left tag has been found. Check whether the
                     * stuff between the tags validates.
                     */

                    if (seenTags.contains(leftTag))
                        return -1; // Can't throw an exception here: It'll mess
                    // up error handling for other "bad"
                    // strings.

                    // Put the tag we just found on the list of "seen" tags so
                    // that matchBrackets can throw an error if it occurs again.
                    ArrayList<String> newSeenTags = new ArrayList<String>(seenTags); // This
                    // should
                    // be a
                    // clone().
                    newSeenTags.add(leftTag);

                    String innerText = checkedSubstring.substring(leftTagEnd, rightTagMatcher.start());
                    String remainder = checkedSubstring.substring(rightTagMatcher.end());

                    if (leftTag.equals("img") || (leftTag.equals("url") && leftTagAttribute == null)) {
                        // Don't attempt to match brackets inside simple
                        // URL-containing tags. Instead, check their content.
                        checkTagContent(innerText, leftTag, offset + leftTagEnd);

                        // Check the remainder of the string. Pass the old
                        // seenTags, since this is effectively "fresh".
                        if (matchBrackets(remainder, offset + rightTagMatcher.end(), seenTags) >= 0)
                            return checkedSubstring.length();
                    } else if (matchBrackets(innerText, offset + leftTagEnd, newSeenTags) >= 0) {
                        if (matchBrackets(remainder, offset + rightTagMatcher.end(), seenTags) >= 0)
                            return checkedSubstring.length();
                    }

                    // If not, go to the next match.
                    tagSearchStart = rightTagMatcher.end();
                } else {
                    Matcher badRightTagMatcher = tagMatcher.matcher(checkedSubstring.substring(leftTagEnd));
                    while (badRightTagMatcher.find()) {
                        analyzeTag(badRightTagMatcher, offset + leftTagEnd, "right", seenTags.size());
                    }

                    return -1;
                }
            }

            return -1;
        } else {
            if (lastSeenTag != null)
                checkTagContent(checkedSubstring, lastSeenTag, offset);
            return checkedSubstring.length();
        }
    }


    /**
     * Given [tag]content[/tag] or [tag=content]ignored text[/tag], checks the
     * validity of content.
     * 
     * @input content the string to be checked
     * @input tag the surrounding tag
     * @int start integer offset from the beginning of checkedString
     * 
     * @return true if the content checks out
     * @throws BbTagFormatException
     */
    private boolean checkTagContent(String content, String tag, int start) throws BbTagFormatException {
        Matcher illegalCharMatcher = illegalChars.matcher(content);

        if (tag.equals("img")) {
            if (content.indexOf('\n') >= 0) {
                throw new BbTagFormatException(checkedString, start, "Return character found in image link");
            }
            if (!content.matches("https?://.*")) {
                throw new BbTagFormatException(checkedString, start, "Image link does not start with http[s]://");
            }
            if (content.matches(".*\\s.*")) {
                throw new BbTagFormatException(checkedString, start, "Space characters found in image link");
            }
            if (illegalCharMatcher.find()) {
                throw new BbTagFormatException(checkedString, start, "Illegal characters in link: " + illegalCharMatcher.group(1));
            }
        } else if (tag.equals("url")) {
            if (illegalLink.matcher(content).find()) {
                throw new BbTagFormatException(checkedString, start, "Illegal link");
            }
            if (content.indexOf('\n') >= 0) {
                throw new BbTagFormatException(checkedString, start, "Return character found in link");
            }
            if (content.matches("https?://.*\\s.*")) // We may get
            // [url=attribute]
            // links, so check the
            // interior only if
            // there's an 'http'
            {
                throw new BbTagFormatException(checkedString, start, "Space characters found in link");
            }
            if (illegalCharMatcher.find()) {
                throw new BbTagFormatException(checkedString, start, "Illegal characters in link: " + illegalCharMatcher.group(1));
            }
        }

        return true;
    }


    private boolean analyzeTag(Matcher badTagMatcher, int start, String side, int depth) throws BbTagFormatException {
        String leftBracket = badTagMatcher.group(1);
        int leftBracketStart = badTagMatcher.start(1);

        String badLeftSlash = badTagMatcher.group(2);
        int badLeftSlashStart = badTagMatcher.start(2);

        String tag = badTagMatcher.group(3);
        int tagStart = badTagMatcher.start(3);

        String tagAttribute = badTagMatcher.group(4);
        int tagAttributeStart = badTagMatcher.start(4);

        String badRightSlash = badTagMatcher.group(5);
        int badRightSlashStart = badTagMatcher.end(5);

        String rightBracket = badTagMatcher.group(6);
        int rightBracketStart = badTagMatcher.end(6);

        String errorMessage;

        if (badRightSlash.length() > 0) {
            if (badRightSlash.charAt(0) == '\\' || tagAttribute == null || !tag.equals("url")) {
                errorMessage = "Slash on right side of " + side + " tag [" + tag + "]: " + badRightSlash;
                throw new BbTagFormatException(checkedString, badRightSlashStart + start, errorMessage);
            }
        }
        if (leftBracket.charAt(0) != '[') {
            errorMessage = "Bad " + side + " tag start: " + leftBracket;
            throw new BbTagFormatException(checkedString, leftBracketStart + start, errorMessage);
        }
        if (rightBracket.charAt(0) != ']') {
            errorMessage = "Bad " + side + " tag end: " + rightBracket;
            throw new BbTagFormatException(checkedString, rightBracketStart + start, errorMessage);
        }
        if (Arrays.binarySearch(bbCodeTagList, tag.toLowerCase()) < 0) {
            if (Arrays.binarySearch(bbCodeExtendedTagList, tag.toLowerCase()) < 0) {
                throw new BbTagFormatException(checkedString, tagStart + start, "Unknown BBcode tag: [" + tag + "]");
            } else if (tagAttribute != null) {
                if (tagAttribute.length() == 0) {
                    throw new BbTagFormatException(checkedString, tagAttributeStart + start, "No content after = in: [" + tag + "]");
                }
                if (tagAttribute.matches(".*\\s.*")) {
                    throw new BbTagFormatException(checkedString, tagAttributeStart + start, "Whitespace in URL");
                }
            }
        }
        if (badLeftSlash.length() > 0) {
            if (badLeftSlash.charAt(0) == '\\') {
                throw new BbTagFormatException(checkedString, badLeftSlashStart + start, "Backslash found in " + side + " tag: " + badLeftSlash);
            } else if (badLeftSlash.charAt(0) == '/' && side == "left" && depth == 0) // Left-slash
            // checking
            // is
            // only
            // reliable
            // at
            // level
            // 0
            // recursion.
            {
                errorMessage = "Slash on left side of " + side + " tag: " + badLeftSlash;
                throw new BbTagFormatException(checkedString, badLeftSlashStart + start, errorMessage);
            }
        }

        return true;
    }


    private BbTagFormatException checkForWhitespaceInTags(BbTagFormatException e) {
    	
        Matcher outerSpaceMatcher = outerSpacePattern.matcher(checkedString);
        Matcher innerSpaceMatcher = innerSpacePattern.matcher(checkedString);

        if (outerSpaceMatcher.find()) {
            e.setErrorExplanation(e.getErrorExplanation() + "<br />\n" + "Possible contributing factor: Space around tag: " + outerSpaceMatcher.group(1));
        }
        if (innerSpaceMatcher.find()) {
            e.setErrorExplanation(e.getErrorExplanation() + "<br />\n" + "Possible contributing factor: Space inside tag: " + innerSpaceMatcher.group(1));
        }

        return e;
    }


    private BbTagFormatException countBalancedTags(BbTagFormatException e) {
        Matcher leftTagMatcher = correctLeftTagCounter.matcher(checkedString);
        Matcher rightTagMatcher = correctRightTagCounter.matcher(checkedString);

        HashMap<String, Integer> leftTags = new HashMap<String, Integer>();
        HashMap<String, Integer> rightTags = new HashMap<String, Integer>();

        // Count the left and right tags.
        while (leftTagMatcher.find()) {
            leftTags.put(leftTagMatcher.group(1).toLowerCase(), (leftTags.get(leftTagMatcher.group(1)) == null) ? 1 : leftTags.get(leftTagMatcher.group(1)) + 1);
        }
        while (rightTagMatcher.find()) {
            rightTags.put(rightTagMatcher.group(1).toLowerCase(), (rightTags.get(rightTagMatcher.group(1)) == null) ? 1 : rightTags.get(rightTagMatcher.group(1)) + 1);
        }

        HashSet<String> tagBag = new HashSet<String>();
        tagBag.addAll(leftTags.keySet());
        tagBag.addAll(rightTags.keySet());

        Iterator<String> tags = tagBag.iterator();
        while (tags.hasNext()) {
            String tag = tags.next();
            int lt = (leftTags.get(tag) == null) ? 0 : leftTags.get(tag);
            int rt = (rightTags.get(tag) == null) ? 0 : rightTags.get(tag);

            if (lt > rt) {
                e.setErrorExplanation(e.getErrorExplanation() + "<br />\n" + "Possible contributing factor: Too many [" + tag + "] tags");
            } else if (lt < rt) {
                e.setErrorExplanation(e.getErrorExplanation() + "<br />\n" + "Possible contributing factor: Too many [/" + tag + "] tags");
            }
        }

        return e;
    }


    /**
     * Detect simple (no-attribute) nested tags.
     */
    private BbTagFormatException checkForNestedTags(BbTagFormatException e) {
        Matcher nestedTagMatcher = nestedBbCodePattern.matcher(checkedString);

        if (nestedTagMatcher.find()) {
            e.setErrorExplanation(e.getErrorExplanation() + "<br />\n" + "Possible contributing factor: Potential nested tag: [" + nestedTagMatcher.group(1) + "]");
        }

        return e;
    }
}
